<?php
/**
 * Created by PhpStorm.
 * User: Qch
 * Date: 2018/9/9
 * Time: 7:37
 */

namespace Aoe\Util;


use Throwable;

/**
 * # HTTP 单文件上传
 *   * 多文件应该由客户端多次发起
 *
 *    (new UploadFile())->rule()->save()
 *
 * <form enctype="multipart/form-data" action="__URL__" method="POST">
 *    <!-- MAX_FILE_SIZE must precede the file input field -->
 *    <input type="hidden" name="MAX_FILE_SIZE" value="30000" />
 *    <!-- Name of input element determines name in $_FILES array -->
 *    Send this file: <input name="file" type="file" />
 *    <input type="submit" value="Send File" />
 * </form>
 *
 * PHP文件上传，php.ini中的配置项为：
 *      file_uploads (ON)  PHP脚本是否可以接受HTTP文件上传
 *      upload_max_filesize (2M)  限制PHP处理上传文件大小的最大值，此值必须小于post_max_size值
 *      post_max_size (8M)  POST方法接受信息的最大值
 *                                 除了上传文件，可能有其他的表单域，所以要大于upload_max_filesize
 *      upload_tmp_dir (NULL)  上传文件临时路径，对于拥有此服务器的进程用户必须是可写的。
 *                                默认为操作系统的临时文件夹，Linux的是在/tmp目录下
 *
 * 全局数组$_FILES，其中file为表单中name的值:
 *      $_FILES['file']['name'] 客户端机器文件的原名称。
 *      $_FILES['file']['type'] 文件的 MIME 类型，如果浏览器提供此信息的话。
 *                              一个例子是“image/gif”。
 *                             不过此 MIME 类型在 PHP 端并不检查，因此不要想当然认为有这个值。
 *      $_FILES['file']['size'] 已上传文件的大小，单位为字节。
 *      $_FILES['file']['tmp_name'] 文件被上传后在服务端储存的临时文件名。
 *      $_FILES['file']['error'] 和该文件上传相关的错误代码。
 *                UPLOAD_ERR_OK         其值为 0，没有错误发生，文件上传成功。
 *                UPLOAD_ERR_INI_SIZE   其值为 1，上传的文件超过了 php.ini 中 upload_max_filesize 选项限制的值。
 *                UPLOAD_ERR_FORM_SIZE  其值为 2，上传文件的大小超过了 HTML 表单中 MAX_FILE_SIZE 选项指定的值。
 *                UPLOAD_ERR_PARTIAL    其值为 3，文件只有部分被上传。
 *                UPLOAD_ERR_NO_FILE    其值为 4，没有文件被上传。
 *                UPLOAD_ERR_NO_TMP_DIR 其值为 6，找不到临时文件夹。 PHP 4.3.10 和 PHP 5.0.3 引进。
 *                UPLOAD_ERR_CANT_WRITE 其值为 7，文件写入失败。 PHP 5.1.0 引进。
 * 两个函数：
 *      bool is_uploaded_file(string filename)。  用于判断是否为HTTP POST上传的文件，如果是则返回true。
 *          参数必须类似$_FILES['file']['tmp_name']，不能是$_FILES['file']['name']，
 *          用于防止潜在的攻击者对原本不能通过脚本交互的文件进行非法管理。
 *      bool move_uploaded_file(string filename,string destination)。
 *          将HTTP POST上传的临时文件移动到新的路径。成功返回TRUE
 *
 * 上传文件流程：
 *
 *      通过$_FILES['file']['error']判断是否有错误产生，有错误则提示并退出脚本。
 *      通过$_FILES['file']['type']判断是否为允许的上传类型，如果不在允许上传的类型中，提示并退出脚本。
 *      把$_FILES['file']['name']分割为数组，获取文件后缀名，
 *      通过$_FILES['file']['size']判断是否超出了PHP限制的大小。
 *      通过is_uploaded_file()函数和move_uploaded_file()将文件上传到相应的目录中。
 */
class Upload
{
    const string ERR_NO_FILE  = '没有可用的上传文件';
    const string ERR_CACHE    = '上传文件缓存失败';
    const string ERR_DIR      = '无效的上传路径';
    const string ERR_WRITABLE = '上传路径不可写';
    const string ERR_SIZE     = '文件过大';
    const string ERR_EXT      = '非法的文件后缀';
    const string ERR_MIME     = '无效的文件元数据';
    const string ERR_IMG      = '无效的图像';

    protected array $info;
    
    /**
     * @var string dir + name
     */
    protected(set) string $fullName {
        get {
            return $this->fullName;
        }
    }
    /**
     * @var string name for url
     */
    protected(set) string $name {
        get {
            return $this->name;
        }
    }
    /**
     * @var string 原始文件名
     */
    protected(set) string $original {
        get {
            return $this->original;
        }
    }
    /**
     * @var string 原始mime
     */
    protected string $type;
    /**
     * @var int 文件大小
     */
    protected(set) int $size {
        get {
            return $this->size;
        }
    }
    /**
     * @var string 后缀
     */
    protected string $ext = '';

    /**
     * @var bool 处理结果
     */
    protected string | false $error = false;
    
    /**
     * ## 上传的文件
     *
     * @param string $path 文件存放目录
     * @param ?array $rule 校验规则
     */
    public function __construct(string $path, protected ?array $rule = null)
    {
        $field = $rule['field'] ?? 'file';

        // 文件上传失败，捕获错误代码
        if (empty($_FILES) or !isset($_FILES[$field]) or empty($_FILES[$field])) {
            $this->error = self::ERR_NO_FILE;
            return;
        }

        $this->info = $_FILES[$field];
        $info = $_FILES[$field];
        
        // 检测合法性
        if (!isset($info['tmp_name']) or !is_uploaded_file($info['tmp_name'])) {
            $this->error = self::ERR_CACHE;
            return;
        }

        $this->original = $info['name'];
        $this->type     = $info['type'];
        $this->size     = $info['size'];
        $this->ext      = pathinfo($this->original, PATHINFO_EXTENSION);

        $path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
        $name = $this->buildSaveName();

        $this->name     = $name;
        $this->fullName = "$path$name";
        
        try {
            // 验证上传
            if ($rule) $this->check($rule);
            $this->save();
        } catch (Throwable $e) {
            $this->error = $e->getMessage();
        }
    }
    
    /**
     * 检测上传文件
     *
     * @param array $rule
     *
     * @throws Exception
     */
    protected function check(array $rule): void
    {
        /* 检查文件大小 */
        if (isset($rule['size']) && !$this->checkSize($rule['size']))
            throw new Exception(self::ERR_SIZE);
        
        /* 检查文件 Mime 类型 */
        if (isset($rule['mime']) && !$this->checkMime($rule['mime']))
            throw new Exception(self::ERR_MIME);
        
        /* 检查文件后缀 */
        if (isset($rule['ext']) && !$this->checkExt($rule['ext']))
            throw new Exception(self::ERR_EXT);
        
        /* 检查图像文件 */
        if (isset($rule['img']) && !$this->checkImg())
            throw new Exception(self::ERR_IMG);
    }
    
    /**
     * 检测上传文件大小
     *
     * @param integer $size 最大大小
     *
     * @return bool
     */
    protected function checkSize(int $size): bool
    {
        return $this->size <= $size;
    }
    
    /**
     * 检测上传文件类型
     *
     * @param array|string $mime 允许类型
     *
     * @return bool
     */
    protected function checkMime(array | string $mime): bool
    {
        $mime = is_string($mime) ? explode(',', $mime) : $mime;
        
        return in_array(strtolower($this->getMime()), $mime);
    }
    
    /**
     * 获取文件类型信息
     *
     * @access public
     * @return string
     */
    public function getMime(): string
    {
        $info = finfo_open(FILEINFO_MIME_TYPE);
        
        return finfo_file($info, $this->fullName);
    }
    
    /**
     * 检测上传文件后缀
     *
     * @access public
     *
     * @param array|string $ext 允许后缀
     *
     * @return bool
     */
    protected function checkExt(array | string $ext): bool
    {
        if (is_string($ext))
            $ext = explode(',', $ext);
        
        $extension = strtolower(pathinfo($this->fullName, PATHINFO_EXTENSION));
        
        return in_array($extension, $ext);
    }
    
    /**
     * 检测图像文件
     *
     * @access public
     * @return bool
     */
    protected function checkImg(): bool
    {
        $extension = strtolower(pathinfo($this->fullName, PATHINFO_EXTENSION));
        
        // 如果上传的不是图片，或者是图片而且后缀确实符合图片类型则返回 true
        return !in_array($extension, ['gif', 'jpg', 'jpeg', 'bmp', 'png', 'swf']) ||
               in_array($this->getImageType($this->fullName), [1, 2, 3, 4, 6, 13]);
    }
    
    /**
     * 判断图像类型
     *
     * @param string $image 图片名称
     *
     * @return bool|int
     */
    protected function getImageType(string $image): bool | int
    {
        if (function_exists('exif_imagetype'))
            return exif_imagetype($image);
        
        
        $info = getimagesize($image);
        return $info ? $info[2] : false;
    }
    
    /**
     * 尝试保存文件
     *
     * @throws Exception
     */
    protected function save(): void
    {
        $dir = dirname($this->fullName);
        
        if (!is_dir($dir) and !mkdir($dir, 0755, true))
            throw new Exception(self::ERR_DIR);

        if (!move_uploaded_file($this->info['tmp_name'], $this->fullName))
            throw new Exception(self::ERR_WRITABLE);
    }
    
    /**
     * 获取保存文件名
     *
     * @return string
     */
    protected function buildSaveName(): string
    {
        $name = $this->rule['name'] ?? null;
        if (is_callable($name)) return call_user_func_array($name, [$this->original]);
        
        return md5(microtime(true)) . '.' . $this->ext;
    }

    /** @noinspection PhpUnused */

    public function isError(): false | string
    {
        return $this->error;
    }
    
    public function url(string $prefix): string
    {
        return '/' . trim($prefix, '/') . '/'
               . ltrim(str_replace('\\', '/', $this->name), '/');
    }

}